Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 85db32c07b3308aa7fbd2813ab685626e759679c


Parents : f27bf64
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-06T23:16:47-05:00

feat(plugin): update plugin system with runtime enable/disable functionality and improve UI integration

Changes

31 files changed, 623 insertions(+), 436 deletions(-)

M CHANGELOG.md +26 -2

Diff

diff --git a/.dockerignore b/.dockerignore
index 9f9621c4..53f90ce9 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -87,6 +87,7 @@ meshchat-config/
storage/
testing/
telemetry_test_lxmf/
+temp-tests/
# Logs
*.log

diff --git a/.gitignore b/.gitignore
index 9a168ed8..d78512a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -84,6 +84,7 @@ android/.idea/
storage/
testing/
telemetry_test_lxmf/
+temp-tests/
# Logs
*.log

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 60617a99..8811a7ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,15 +4,39 @@ All notable changes to this project will be documented in this file.
## [4.8.0] - 2026-07-TBD
+### Added
+
+- **Plugins**: JS/WASM plugin system with capability-based permissions, Worker-sandboxed frontend runtime, wasmtime-backed backend runtime, generic `/api/v1/plugins/*` API, and plugin management under **Settings → Maintenance** (drag-and-drop ZIP install, enable/disable, remove with confirmation, UI/WASM badges, empty state).
+- **Plugins**: Contribution-point registries for sidebar navigation, tools catalog, command palette, settings sections, and typed WebSocket event dispatch — core UI surfaces are data-driven instead of hardcoded per component.
+- **Plugins**: **Bundled i18n** — plugins ship `locales/{locale}.json` in the package; the host loads labels from plugin assets so third-party plugins do not need changes to MeshChatX main locale files.
+- **Plugins**: Declarative UI slot vocabulary in `PluginSlotRenderer` / `PluginSlotNode` — sections, action button rows, badges, card lists, grid rows, and text variants for plugin tool pages.
+- **Plugins**: Bundled **Mesh Observatory** example plugin — live announce feed, searchable path table with hop/interface/state columns, and announce-driven refresh.
+- **Plugins**: Security guardrails in `plugin_guard.py` — ZIP size/magic validation, zip-slip protection, asset path normalization, WASM size/magic checks, invoke payload limits, and an error budget that auto-disables misbehaving plugins after repeated failures.
+- **Plugins**: `POST /api/v1/plugins/{id}/report-failure` for frontend worker crash reporting; kill-switch broadcasts over WebSocket when a plugin is auto-disabled.
+- **Dependencies**: Added **wasmtime** for backend WASM plugin execution.
+- **Plugins**: `--disable-plugins` CLI flag and `MESHCHAT_DISABLE_PLUGINS` environment variable to disable the plugin system entirely at runtime.
+
### Fixed
+- **Plugins**: Plugin worker `postRequest` Promise wrapper, plugin locale loading at boot, cached UI on page open, and slot renderer recursion for nested column/list/row children.
+- **Plugins**: Mesh Observatory layout — spaced action buttons, section cards, truncated interface names, and state badges instead of squashed single-line rows.
- **RNode / Android**: Hardened `rnode_support` startup guards — desktop TCP RNode no longer incorrectly requires pyserial; desktop BLE now checks for bleak instead of pyserial; whitespace-only Bluetooth ports classify correctly; invalid `tcp:///` hosts are no longer backfilled; `RNodeIPInterface` entries get `tcp_host` backfill on Android; RNodeMulti sibling sub-interfaces with invalid TX power are detected and disabled; txpower guard honors both `enabled` and `interface_enabled` keys.
+- **Reticulum config**: Startup repair helpers validate required sections and parseability before applying default config (`reticulum_config_guard.py`).
+- **WebSocket / security**: Config mutators over WebSocket are restricted — sensitive settings (e.g. `auth_enabled`, `auth_password_hash`) must use CSRF-protected HTTP endpoints (`websocket_config_guard.py`).
- **Settings**: Tabbed settings navigation with section-to-tab mapping, search across tabs, and `SettingsNav` component.
+- **Settings**: Plugin settings search no longer treats `index.mu` / `index.html` literals as missing i18n keys.
-### Added
+### Changed
+
+- **Settings**: Settings section search keywords moved into `settingsSectionRegistry` for reuse by plugins and core sections.
+- **App shell**: WebSocket handling in `App.vue` migrated to typed per-event handlers via `wsEventRegistry`.
+- **Locales**: Main app locale files retain only **Settings → Plugins** UI strings; per-plugin copy lives in each plugin bundle.
+
+### Tests
+- **Plugins**: Registry, manifest, plugin labels, WebSocket event router, `PluginManager`, and `plugin_guard` unit tests (including zip-slip rejection and fuzzed invalid install payloads); HTTP API contract updated for plugin routes.
- **Tests**: Expanded `rnode_support` coverage (67 tests, including hypothesis fuzzing and full startup repair sequence); settings tabs contract tests, i18n key validation, and `SettingsNav` component tests.
-- **Locales**: Finnish (`fi`) translation updates for settings tabs, archives, banishment, and common UI strings.
+- **Security**: `test_websocket_config_security.py` and `test_reticulum_config_guard.py` for WebSocket config denylist and Reticulum config repair behavior.
## [4.7.2] - 2026-07-06

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 908f0eba..2170e59a 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -381,8 +381,10 @@ class ReticulumMeshChat:
rns_loglevel: str | None = None,
migration_context: dict | None = None,
memory_diag_enabled: bool = False,
+ plugins_enabled: bool = True,
):
self.running = True
+ self.plugins_enabled = plugins_enabled
self._memory_diag_enabled = memory_diag_enabled
self._mem_diag = None
self.migration_context = (
@@ -949,7 +951,8 @@ class ReticulumMeshChat:
self.page_node_manager.load_nodes()
self.page_node_manager.start_all()
self.plugin_manager.set_app(self)
- self.plugin_manager.install_bundled_examples()
+ if self.plugins_enabled:
+ self.plugin_manager.install_bundled_examples()
# Create new context
context = IdentityContext(identity, self)
@@ -4370,6 +4373,7 @@ class ReticulumMeshChat:
"listen_port": self.listen_port,
"https_enabled": self.use_https,
"is_loopback_bind": _is_loopback_bind_host(self.listen_host),
+ "plugins_enabled": self.plugins_enabled,
**self._landlock_status_dict(),
},
)
@@ -10994,10 +10998,19 @@ class ReticulumMeshChat:
@routes.get("/api/v1/plugins")
async def plugins_list(request):
- return web.json_response({"plugins": self.plugin_manager.list_plugins()})
+ return web.json_response(
+ {
+ "plugins": self.plugin_manager.list_plugins(),
+ "plugins_enabled": self.plugins_enabled,
+ }
+ )
@routes.post("/api/v1/plugins/install")
async def plugins_install(request):
+ if not self.plugins_enabled:
+ return web.json_response(
+ {"message": "Plugins are disabled"}, status=403
+ )
try:
if request.content_type and "multipart" in request.content_type:
reader = await request.multipart()
@@ -11025,6 +11038,10 @@ class ReticulumMeshChat:
@routes.post("/api/v1/plugins/{plugin_id}/enable")
async def plugins_enable(request):
+ if not self.plugins_enabled:
+ return web.json_response(
+ {"message": "Plugins are disabled"}, status=403
+ )
plugin_id = request.match_info["plugin_id"]
try:
plugin = await asyncio.to_thread(self.plugin_manager.enable, plugin_id)
@@ -11068,13 +11085,19 @@ class ReticulumMeshChat:
self.plugin_manager.report_failure, plugin_id, reason, source
)
if plugin is None:
- return web.json_response({"message": "Plugin not found"}, status=404)
+ return web.json_response(
+ {"message": "Plugin not found"}, status=404
+ )
return web.json_response(plugin)
except Exception as e:
return web.json_response({"message": str(e)}, status=400)
@routes.post("/api/v1/plugins/{plugin_id}/invoke")
async def plugins_invoke(request):
+ if not self.plugins_enabled:
+ return web.json_response(
+ {"message": "Plugins are disabled"}, status=403
+ )
plugin_id = request.match_info["plugin_id"]
try:
data = await request.json()
@@ -11098,6 +11121,10 @@ class ReticulumMeshChat:
@routes.get("/api/v1/plugins/{plugin_id}/asset/{asset_path:.*}")
async def plugins_asset(request):
+ if not self.plugins_enabled:
+ return web.json_response(
+ {"message": "Plugins are disabled"}, status=403
+ )
plugin_id = request.match_info["plugin_id"]
asset_path = request.match_info["asset_path"]
try:
@@ -19687,6 +19714,13 @@ def main():
help="Enable tracemalloc-based memory diagnostics. Can also be set via MESHCHAT_MEMORY_DIAG environment variable.",
)
+ parser.add_argument(
+ "--disable-plugins",
+ action="store_true",
+ default=env_bool("MESHCHAT_DISABLE_PLUGINS", False),
+ help="Disable the plugin system entirely. Can also be set via MESHCHAT_DISABLE_PLUGINS environment variable.",
+ )
+
args = parser.parse_args()
ssl_cert = (args.ssl_cert or "").strip() or None
@@ -19834,6 +19868,7 @@ def main():
rns_loglevel=rns_log_cli,
migration_context=migration_context,
memory_diag_enabled=args.memory_diag,
+ plugins_enabled=not args.disable_plugins,
)
# store recovery on app for wiring with identity context

diff --git a/meshchatx/src/backend/data/plugins/mesh-observatory/frontend/main.js b/meshchatx/src/backend/data/plugins/mesh-observatory/frontend/main.js
index 64363721..0e8d6799 100644
--- a/meshchatx/src/backend/data/plugins/mesh-observatory/frontend/main.js
+++ b/meshchatx/src/backend/data/plugins/mesh-observatory/frontend/main.js
@@ -20,6 +20,20 @@ function shortHash(hash) {
return `${hash.slice(0, 10)}…${hash.slice(-6)}`;
}
+function shortInterface(name) {
+ if (!name || typeof name !== "string") {
+ return "—";
+ }
+ const match = name.match(/\[([^\]]+)\]/);
+ if (match) {
+ return match[1];
+ }
+ if (name.length > 36) {
+ return `${name.slice(0, 18)}…${name.slice(-10)}`;
+ }
+ return name;
+}
+
function hopLabel(api, hops) {
if (hops == null) {
return formatLabel(api, "hops_unknown");
@@ -30,6 +44,19 @@ function hopLabel(api, hops) {
return formatLabel(api, "hops_many", { count: hops });
}
+function stateNode(api, state) {
+ let label = formatLabel(api, "state_unknown");
+ let variant = "muted";
+ if (state === 1) {
+ label = formatLabel(api, "state_responsive");
+ variant = "success";
+ } else if (state === 2) {
+ label = formatLabel(api, "state_unresponsive");
+ variant = "danger";
+ }
+ return { type: "badge", label, variant };
+}
+
/**
* @param {{ t: (key: string) => string, invoke: Function, setUi: Function, onAction: Function, onEvent: Function, onRefresh: Function, getInputValue: Function }} api
*/
@@ -39,16 +66,6 @@ export async function activate(api) {
/** @type {{ paths: Array<Record<string, unknown>>, total: number, responsive: number, unresponsive: number }} */
let pathData = { paths: [], total: 0, responsive: 0, unresponsive: 0 };
- function stateLabel(state) {
- if (state === 1) {
- return formatLabel(api, "state_responsive");
- }
- if (state === 2) {
- return formatLabel(api, "state_unresponsive");
- }
- return formatLabel(api, "state_unknown");
- }
-
async function refreshPaths() {
const search = (api.getInputValue("path-search") || "").trim();
pathData = await api.invoke("readPaths", {
@@ -63,7 +80,8 @@ export async function activate(api) {
if (!announceFilter) {
return true;
}
- const haystack = `${entry.aspect || ""} ${entry.destination_hash || ""} ${entry.app_data || ""}`.toLowerCase();
+ const haystack =
+ `${entry.aspect || ""} ${entry.destination_hash || ""} ${entry.app_data || ""}`.toLowerCase();
return haystack.includes(announceFilter);
});
@@ -77,87 +95,104 @@ export async function activate(api) {
},
{
type: "text",
+ variant: "body",
value: formatLabel(api, "description"),
},
{
- type: "text",
- variant: "title",
- value: formatLabel(api, "announces_section"),
+ type: "actions",
+ items: [
+ {
+ type: "button",
+ id: "refresh",
+ label: formatLabel(api, "refresh"),
+ },
+ ],
},
{
- type: "text",
- value: formatLabel(api, "announce_stats", {
+ type: "section",
+ title: formatLabel(api, "announces_section"),
+ description: formatLabel(api, "announce_stats", {
shown: Math.min(filteredAnnounces.length, 40),
total: announces.length,
}),
+ children: [
+ {
+ type: "input",
+ id: "announce-filter",
+ label: formatLabel(api, "filter"),
+ placeholder: formatLabel(api, "filter_placeholder"),
+ },
+ {
+ type: "actions",
+ items: [
+ {
+ type: "button",
+ id: "clear-announces",
+ variant: "secondary",
+ label: formatLabel(api, "clear_feed"),
+ },
+ ],
+ },
+ {
+ type: "list",
+ variant: "cards",
+ emptyText: formatLabel(api, "no_announces"),
+ items: filteredAnnounces.slice(0, 40).map((entry) => ({
+ type: "row",
+ variant: "announce-card",
+ children: [
+ { type: "text", variant: "mono", value: entry.receivedAt || "—" },
+ { type: "text", variant: "stat", value: entry.aspect || "—" },
+ { type: "text", variant: "mono", value: shortHash(entry.destination_hash) },
+ {
+ type: "text",
+ variant: "caption",
+ value: (entry.app_data || "").slice(0, 72) || "—",
+ },
+ ],
+ })),
+ },
+ ],
},
{
- type: "input",
- id: "announce-filter",
- label: formatLabel(api, "filter"),
- placeholder: formatLabel(api, "filter_placeholder"),
- },
- {
- type: "button",
- id: "refresh",
- label: formatLabel(api, "refresh"),
- },
- {
- type: "button",
- id: "clear-announces",
- label: formatLabel(api, "clear_feed"),
- },
- {
- type: "list",
- emptyText: formatLabel(api, "no_announces"),
- items: filteredAnnounces.slice(0, 40).map((entry) => ({
- type: "row",
- children: [
- { type: "text", variant: "mono", value: entry.receivedAt || "—" },
- { type: "text", value: entry.aspect || "—" },
- { type: "text", variant: "mono", value: shortHash(entry.destination_hash) },
- {
- type: "text",
- value: (entry.app_data || "").slice(0, 56) || "—",
- },
- ],
- })),
- },
- {
- type: "text",
- variant: "title",
- value: formatLabel(api, "paths_section"),
- },
- {
- type: "text",
- value: formatLabel(api, "path_stats", {
+ type: "section",
+ title: formatLabel(api, "paths_section"),
+ description: formatLabel(api, "path_stats", {
total: pathData.total || 0,
responsive: pathData.responsive || 0,
unresponsive: pathData.unresponsive || 0,
}),
- },
- {
- type: "input",
- id: "path-search",
- label: formatLabel(api, "path_search"),
- placeholder: formatLabel(api, "path_search_placeholder"),
- },
- {
- type: "list",
- emptyText: formatLabel(api, "no_paths"),
- items: (pathData.paths || []).map((entry) => ({
- type: "row",
- children: [
- {
- type: "text",
- variant: "mono",
- value: shortHash(entry.destination_hash),
- },
- { type: "text", value: hopLabel(api, entry.hops) },
- { type: "text", value: entry.interface || "—" },
- { type: "text", value: stateLabel(entry.state) },
- ],
- })),
+ children: [
+ {
+ type: "input",
+ id: "path-search",
+ label: formatLabel(api, "path_search"),
+ placeholder: formatLabel(api, "path_search_placeholder"),
+ },
+ {
+ type: "list",
+ variant: "cards",
+ emptyText: formatLabel(api, "no_paths"),
+ items: (pathData.paths || []).map((entry) => ({
+ type: "row",
+ variant: "card",
+ children: [
+ {
+ type: "text",
+ variant: "mono",
+ value: shortHash(entry.destination_hash),
+ },
+ { type: "text", variant: "stat", value: hopLabel(api, entry.hops) },
+ {
+ type: "text",
+ variant: "caption",
+ value: shortInterface(entry.interface),
+ },
+ stateNode(api, entry.state),
+ ],
+ })),
+ },
+ ],
},
],
});

diff --git a/meshchatx/src/backend/data/plugins/mesh-observatory/locales/en.json b/meshchatx/src/backend/data/plugins/mesh-observatory/locales/en.json
index dcf1d3d6..ab5a8a09 100644
--- a/meshchatx/src/backend/data/plugins/mesh-observatory/locales/en.json
+++ b/meshchatx/src/backend/data/plugins/mesh-observatory/locales/en.json
@@ -1,23 +1,23 @@
{
- "nav": "Mesh Observatory",
- "title": "Mesh Observatory",
- "description": "Watch live announces and browse your Reticulum path table in one place.",
- "announces_section": "Live announces",
- "announce_stats": "Showing {shown} of {total} captured announces",
- "filter": "Filter announces",
- "filter_placeholder": "Aspect, hash, or app data",
- "refresh": "Refresh paths",
- "clear_feed": "Clear announce feed",
- "no_announces": "No announces captured yet. Activity will appear here as the mesh announces.",
- "paths_section": "Path table",
- "path_stats": "{total} routes — {responsive} responsive, {unresponsive} unresponsive",
- "path_search": "Search paths",
- "path_search_placeholder": "Destination or via hash",
- "no_paths": "No paths match your search.",
- "hops_unknown": "Unknown hops",
- "hops_one": "1 hop",
- "hops_many": "{count} hops",
- "state_responsive": "Responsive",
- "state_unresponsive": "Unresponsive",
- "state_unknown": "Unknown"
+ "nav": "Mesh Observatory",
+ "title": "Mesh Observatory",
+ "description": "Watch live announces and browse your Reticulum path table in one place.",
+ "announces_section": "Live announces",
+ "announce_stats": "Showing {shown} of {total} captured announces",
+ "filter": "Filter announces",
+ "filter_placeholder": "Aspect, hash, or app data",
+ "refresh": "Refresh paths",
+ "clear_feed": "Clear announce feed",
+ "no_announces": "No announces captured yet. Activity will appear here as the mesh announces.",
+ "paths_section": "Path table",
+ "path_stats": "{total} routes — {responsive} responsive, {unresponsive} unresponsive",
+ "path_search": "Search paths",
+ "path_search_placeholder": "Destination or via hash",
+ "no_paths": "No paths match your search.",
+ "hops_unknown": "Unknown hops",
+ "hops_one": "1 hop",
+ "hops_many": "{count} hops",
+ "state_responsive": "Responsive",
+ "state_unresponsive": "Unresponsive",
+ "state_unknown": "Unknown"
}

diff --git a/meshchatx/src/backend/data/plugins/mesh-observatory/plugin.json b/meshchatx/src/backend/data/plugins/mesh-observatory/plugin.json
index ffe1cb61..ddb63ca6 100644
--- a/meshchatx/src/backend/data/plugins/mesh-observatory/plugin.json
+++ b/meshchatx/src/backend/data/plugins/mesh-observatory/plugin.json
@@ -1,41 +1,41 @@
{
- "id": "com.meshchatx.mesh-observatory",
- "version": "1.0.0",
- "apiVersion": 1,
- "name": "Mesh Observatory",
- "description": "Live announce feed and searchable path table for your mesh.",
- "frontend": {
- "entry": "frontend/main.js",
- "type": "js"
- },
- "i18n": {
- "directory": "locales",
- "defaultLocale": "en"
- },
- "contributes": {
- "navItems": [
- {
- "id": "mesh-observatory",
- "route": { "name": "plugin-mesh-observatory" },
- "icon": "chart-line",
- "labelKey": "nav"
- }
- ],
- "toolsPageEntries": [
- {
- "name": "mesh-observatory",
- "route": { "name": "plugin-mesh-observatory" },
- "icon": "chart-line",
- "iconBg": "tool-card__icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-200",
- "titleKey": "title",
- "descriptionKey": "description"
- }
- ]
- },
- "permissions": {
- "hooks": ["announce.received"],
- "managers": ["destinationPath.read"],
- "storage": "isolated",
- "network": "none"
- }
+ "id": "com.meshchatx.mesh-observatory",
+ "version": "1.0.0",
+ "apiVersion": 1,
+ "name": "Mesh Observatory",
+ "description": "Live announce feed and searchable path table for your mesh.",
+ "frontend": {
+ "entry": "frontend/main.js",
+ "type": "js"
+ },
+ "i18n": {
+ "directory": "locales",
+ "defaultLocale": "en"
+ },
+ "contributes": {
+ "navItems": [
+ {
+ "id": "mesh-observatory",
+ "route": { "name": "plugin-mesh-observatory" },
+ "icon": "chart-line",
+ "labelKey": "nav"
+ }
+ ],
+ "toolsPageEntries": [
+ {
+ "name": "mesh-observatory",
+ "route": { "name": "plugin-mesh-observatory" },
+ "icon": "chart-line",
+ "iconBg": "tool-card__icon bg-violet-50 text-violet-600 dark:bg-violet-900/30 dark:text-violet-200",
+ "titleKey": "title",
+ "descriptionKey": "description"
+ }
+ ]
+ },
+ "permissions": {
+ "hooks": ["announce.received"],
+ "managers": ["destinationPath.read"],
+ "storage": "isolated",
+ "network": "none"
+ }
}

diff --git a/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js b/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js
deleted file mode 100644
index e506d17b..00000000
--- a/meshchatx/src/backend/data/plugins/transport-node-monitor/frontend/main.js
+++ /dev/null
@@ -1,73 +0,0 @@
-export async function activate(api) {
- let watchedNodes = [];
- let paths = [];
-
- async function refresh() {
- const state = await api.invoke("getState");
- watchedNodes = state?.watched_nodes || [];
- const pathResult = await api.invoke("readPaths", { destination_hash: null });
- paths = pathResult?.paths || [];
- api.setUi({
- type: "column",
- children: [
- {
- type: "text",
- variant: "title",
- value: api.t("title"),
- },
- {
- type: "text",
- value: api.t("description"),
- },
- {
- type: "input",
- id: "watch-hash",
- label: api.t("watch_hash"),
- placeholder: api.t("watch_hash_placeholder"),
- },
- {
- type: "button",
- id: "add-watch",
- label: api.t("add_watch"),
- },
- {
- type: "list",
- items: watchedNodes.map((hash) => ({
- type: "row",
- children: [
- { type: "text", value: hash },
- {
- type: "text",
- value:
- paths.find((entry) => entry.destination_hash === hash)?.hops?.toString() ??
- api.t("no_path"),
- },
- ],
- })),
- },
- ],
- });
- }
-
- api.onAction(async (actionId) => {
- if (actionId !== "add-watch") {
- return;
- }
- const input = api.getInputValue("watch-hash");
- const hash = (input || "").trim().toLowerCase();
- if (!hash || watchedNodes.includes(hash)) {
- return;
- }
- watchedNodes = [...watchedNodes, hash];
- await api.invoke("setWatchedNodes", { nodes: watchedNodes });
- await refresh();
- });
-
- api.onEvent("announce.received", async () => {
- await refresh();
- });
-
- api.onRefresh(refresh);
-
- await refresh();
-}

diff --git a/meshchatx/src/backend/data/plugins/transport-node-monitor/locales/en.json b/meshchatx/src/backend/data/plugins/transport-node-monitor/locales/en.json
deleted file mode 100644
index 19610822..00000000
--- a/meshchatx/src/backend/data/plugins/transport-node-monitor/locales/en.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "nav": "Transport Nodes",
- "title": "Transport Node Monitor",
- "description": "Watch transport node destinations and path hop counts.",
- "watch_hash": "Destination hash",
- "watch_hash_placeholder": "Enter a destination hash to watch",
- "add_watch": "Add watch",
- "no_path": "No path"
-}

diff --git a/meshchatx/src/backend/data/plugins/transport-node-monitor/plugin.json b/meshchatx/src/backend/data/plugins/transport-node-monitor/plugin.json
deleted file mode 100644
index 5e26f839..00000000
--- a/meshchatx/src/backend/data/plugins/transport-node-monitor/plugin.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "id": "com.meshchatx.transport-node-monitor",
- "version": "1.0.0",
- "apiVersion": 1,
- "name": "Transport Node Monitor",
- "description": "Track watched transport nodes, path hops, and announce activity.",
- "frontend": {
- "entry": "frontend/main.js",
- "type": "js"
- },
- "i18n": {
- "directory": "locales",
- "defaultLocale": "en"
- },
- "contributes": {
- "navItems": [
- {
- "id": "transport-node-monitor",
- "route": { "name": "plugin-transport-node-monitor" },
- "icon": "router-wireless",
- "labelKey": "nav"
- }
- ],
- "toolsPageEntries": [
- {
- "name": "transport-node-monitor",
- "route": { "name": "plugin-transport-node-monitor" },
- "icon": "router-wireless",
- "iconBg": "tool-card__icon bg-sky-50 text-sky-600 dark:bg-sky-900/30 dark:text-sky-200",
- "titleKey": "title",
- "descriptionKey": "description"
- }
- ],
- "settingsSections": [
- {
- "id": "plugins",
- "tab": "maintenance"
- }
- ]
- },
- "permissions": {
- "hooks": ["announce.received"],
- "managers": ["destinationPath.read"],
- "storage": "isolated",
- "network": "none"
- }
-}

diff --git a/meshchatx/src/backend/plugin_manager.py b/meshchatx/src/backend/plugin_manager.py
index f6a2e838..90b6da0d 100644
--- a/meshchatx/src/backend/plugin_manager.py
+++ b/meshchatx/src/backend/plugin_manager.py
@@ -94,6 +94,15 @@ class PluginManager:
def set_app(self, app: Any) -> None:
self.app = app
+ def _plugins_runtime_enabled(self) -> bool:
+ if self.app is None:
+ return True
+ return bool(getattr(self.app, "plugins_enabled", True))
+
+ def _require_runtime_enabled(self) -> None:
+ if not self._plugins_runtime_enabled():
+ raise PermissionError("plugins are disabled")
+
def _load_wasmtime(self):
if self._wasmtime is not None:
return self._wasmtime
@@ -174,6 +183,8 @@ class PluginManager:
return manifest
def list_plugins(self) -> list[dict[str, Any]]:
+ if not self._plugins_runtime_enabled():
+ return []
with self._lock:
rows = []
for record in self._plugins.values():
@@ -206,6 +217,7 @@ class PluginManager:
}
def install_from_directory(self, source_dir: str) -> dict[str, Any]:
+ self._require_runtime_enabled()
manifest_path = os.path.join(source_dir, "plugin.json")
if not os.path.isfile(manifest_path):
raise ValueError("plugin.json not found")
@@ -242,6 +254,7 @@ class PluginManager:
return self.install_from_directory(plugin_root)
def enable(self, plugin_id: str) -> dict[str, Any]:
+ self._require_runtime_enabled()
with self._lock:
record = self._require_plugin(plugin_id)
self._validate_plugin_runtime(record)
@@ -285,6 +298,7 @@ class PluginManager:
conn.commit()
def asset_path(self, plugin_id: str, asset_name: str) -> str:
+ self._require_runtime_enabled()
record = self._require_plugin(plugin_id)
normalized = normalize_asset_path(asset_name)
path = os.path.join(record.install_path, normalized)
@@ -444,6 +458,7 @@ class PluginManager:
def invoke(
self, plugin_id: str, method: str, args: dict[str, Any] | None = None
) -> Any:
+ self._require_runtime_enabled()
record = self._require_plugin(plugin_id)
if not record.enabled:
raise PermissionError("plugin is disabled")
@@ -453,13 +468,6 @@ class PluginManager:
return self.call_manager(
plugin_id, args.get("capability"), args.get("args") or {}
)
- if method == "getState":
- watched = self.storage_get(plugin_id, "watched_nodes")
- return {"watched_nodes": json.loads(watched) if watched else []}
- if method == "setWatchedNodes":
- nodes = args.get("nodes") or []
- self.storage_set(plugin_id, "watched_nodes", json.dumps(nodes))
- return {"ok": True}
if method == "readPaths":
return self.call_manager(plugin_id, "destinationPath.read", args)
backend = record.manifest.get("backend")
@@ -532,16 +540,6 @@ class PluginManager:
data[ptr : ptr + len(payload)] = payload
invoke = instance.exports(store)["invoke"]
invoke(store, ptr, len(payload), 0)
- if method == "getState":
- watched = self.storage_get(record.id, "watched_nodes")
- return {
- "watched_nodes": json.loads(watched) if watched else [],
- "logs": logs,
- }
- if method == "setWatchedNodes":
- nodes = args.get("nodes") or []
- self.storage_set(record.id, "watched_nodes", json.dumps(nodes))
- return {"ok": True, "logs": logs}
return {"ok": True, "logs": logs}
def _ensure_minimal_wasm(self, record: PluginRecord) -> str:
@@ -623,6 +621,8 @@ class PluginManager:
app_data: bytes,
announce_packet_hash: bytes,
) -> None:
+ if not self._plugins_runtime_enabled():
+ return
payload = {
"aspect": aspect,
"destination_hash": destination_hash.hex()
@@ -702,6 +702,8 @@ class PluginManager:
AsyncUtils.run_async(self.app.websocket_broadcast(message))
def install_bundled_examples(self) -> None:
+ if not self._plugins_runtime_enabled():
+ return
bundled_root = os.path.join(os.path.dirname(__file__), "data", "plugins")
if not os.path.isdir(bundled_root):
return

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 837e4485..b3fa99ed 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -595,6 +595,7 @@ import { postRequestPath } from "../js/reticulumPathfinding.js";
import ToneGenerator from "../js/ToneGenerator";
import { listNavItems } from "../js/registries/navRegistry.js";
import { onWsEvent, offWsEvent } from "../js/registries/wsEventRegistry.js";
+import { handleLxmIngestUriResult } from "../js/ingestUriResultNavigation.js";
import logoUrl from "../assets/images/logo.png";
import { loadFeatureSidebarCollapsed, saveFeatureSidebarCollapsed } from "../js/browserLayoutStore";
@@ -1349,43 +1350,11 @@ export default {
}
},
"lxm.ingest_uri.result": async (json) => {
- if (json.ingest_type === "map_view" && json.map_query) {
- const mq = json.map_query;
- const query = {
- lat: String(mq.lat),
- lon: String(mq.lon),
- zoom: String(mq.zoom),
- };
- if (mq.layers) {
- query.layers = mq.layers;
- }
- if (mq.label) {
- query.label = mq.label;
- }
- await this.$router.push({ name: "map", query });
- if (json.status === "error") {
- ToastUtils.error(json.message);
- } else if (json.message) {
- ToastUtils.info(json.message);
- }
- return;
- }
- if (json.ingest_type === "docs_view") {
- const dq = json.docs_query;
- const rel = dq && typeof dq.reticulum === "string" ? dq.reticulum.trim() : "";
- if (rel) {
- await this.$router.push({
- name: "documentation",
- query: { reticulum: encodeURIComponent(rel) },
- });
- } else {
- await this.$router.push({ name: "documentation" });
- }
- if (json.status === "error") {
- ToastUtils.error(json.message);
- } else if (json.message) {
- ToastUtils.info(json.message);
- }
+ const handled = await handleLxmIngestUriResult(json, {
+ router: this.$router,
+ toast: ToastUtils,
+ });
+ if (handled) {
return;
}
if (json.status === "success") {

diff --git a/meshchatx/src/frontend/components/plugins/PluginPage.vue b/meshchatx/src/frontend/components/plugins/PluginPage.vue
index 475228b7..3fa60068 100644
--- a/meshchatx/src/frontend/components/plugins/PluginPage.vue
+++ b/meshchatx/src/frontend/components/plugins/PluginPage.vue
@@ -3,7 +3,7 @@
<template>
<div class="h-full overflow-y-auto p-4 sm:p-6">
<div
- class="mx-auto max-w-3xl rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 sm:p-6"
+ class="mx-auto max-w-5xl rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 sm:p-6 shadow-sm"
>
<PluginSlotRenderer :plugin-id="pluginId" :descriptor="descriptor" @action="onAction" @input="onInput" />
</div>

diff --git a/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue b/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue
index ad8b4fd3..8f5ffee5 100644
--- a/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue
+++ b/meshchatx/src/frontend/components/plugins/PluginSlotNode.vue
@@ -1,25 +1,16 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
- <p
- v-if="node.type === 'text'"
- :class="
- node.variant === 'title'
- ? 'text-lg font-semibold text-gray-900 dark:text-gray-100'
- : node.variant === 'mono'
- ? 'font-mono text-xs text-gray-800 dark:text-gray-200 break-all'
- : 'text-sm text-gray-700 dark:text-gray-300'
- "
- >
+ <p v-if="node.type === 'text'" :class="textClass">
{{ node.value }}
</p>
- <div v-else-if="node.type === 'input'" class="space-y-1">
+ <div v-else-if="node.type === 'input'" class="space-y-1.5">
<label v-if="node.label" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ node.label }}
</label>
<input
- class="w-full rounded-md border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 text-sm"
+ class="w-full rounded-lg border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-500"
type="text"
:placeholder="node.placeholder || ''"
:value="node.value || ''"
@@ -27,29 +18,82 @@
/>
</div>
- <button
- v-else-if="node.type === 'button'"
- type="button"
- class="px-3 py-2 rounded-md bg-blue-600 text-white text-sm hover:bg-blue-700"
- @click="$emit('action', node.id)"
- >
+ <button v-else-if="node.type === 'button'" type="button" :class="buttonClass" @click="$emit('action', node.id)">
{{ node.label }}
</button>
- <div v-else-if="node.type === 'list'" class="space-y-2">
+ <span
+ v-else-if="node.type === 'badge'"
+ class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium whitespace-nowrap"
+ :class="badgeClass"
+ >
+ {{ node.label }}
+ </span>
+
+ <div v-else-if="node.type === 'actions'" class="flex flex-wrap items-center gap-2">
+ <button
+ v-for="action in node.items || []"
+ :key="action.id"
+ type="button"
+ :class="actionButtonClass(action)"
+ @click="$emit('action', action.id)"
+ >
+ {{ action.label }}
+ </button>
+ </div>
+
+ <div
+ v-else-if="node.type === 'section'"
+ class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-gray-50/70 dark:bg-zinc-900/40 p-4 sm:p-5 space-y-4"
+ >
+ <div v-if="node.title || node.description" class="space-y-1">
+ <h2 v-if="node.title" class="text-base font-semibold text-gray-900 dark:text-gray-100">
+ {{ node.title }}
+ </h2>
+ <p v-if="node.description" class="text-sm text-gray-600 dark:text-gray-400">
+ {{ node.description }}
+ </p>
+ </div>
<PluginSlotNode
- v-for="(item, index) in node.items || []"
+ v-for="(child, index) in node.children || []"
:key="index"
- :node="item"
+ :node="child"
@action="$emit('action', $event)"
@input="$emit('input', $event)"
/>
- <p v-if="!(node.items || []).length" class="text-sm text-gray-500 dark:text-gray-400">
+ </div>
+
+ <div v-else-if="node.type === 'list'" class="space-y-2">
+ <div
+ v-if="(node.items || []).length && node.variant === 'cards'"
+ class="rounded-lg border border-gray-200 dark:border-zinc-800 overflow-hidden divide-y divide-gray-200 dark:divide-zinc-800 bg-white dark:bg-zinc-950"
+ >
+ <PluginSlotNode
+ v-for="(item, index) in node.items || []"
+ :key="index"
+ :node="item"
+ @action="$emit('action', $event)"
+ @input="$emit('input', $event)"
+ />
+ </div>
+ <template v-else>
+ <PluginSlotNode
+ v-for="(item, index) in node.items || []"
+ :key="index"
+ :node="item"
+ @action="$emit('action', $event)"
+ @input="$emit('input', $event)"
+ />
+ </template>
+ <p
+ v-if="!(node.items || []).length"
+ class="rounded-lg border border-dashed border-gray-300 dark:border-zinc-700 px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
+ >
{{ node.emptyText || "" }}
</p>
</div>
- <div v-else-if="node.type === 'row'" class="flex items-center justify-between gap-3 text-sm">
+ <div v-else-if="node.type === 'row'" :class="rowClass">
<PluginSlotNode
v-for="(child, index) in node.children || []"
:key="index"
@@ -80,5 +124,60 @@ export default {
},
},
emits: ["action", "input"],
+ computed: {
+ textClass() {
+ const variant = this.node.variant || "body";
+ const map = {
+ title: "text-xl font-bold tracking-tight text-gray-900 dark:text-gray-100",
+ subtitle: "text-base font-semibold text-gray-900 dark:text-gray-100",
+ body: "text-sm leading-relaxed text-gray-700 dark:text-gray-300",
+ caption: "text-xs text-gray-500 dark:text-gray-400",
+ mono: "font-mono text-xs text-gray-800 dark:text-gray-200 break-all",
+ stat: "text-sm font-medium text-gray-800 dark:text-gray-200",
+ };
+ return map[variant] || map.body;
+ },
+ buttonClass() {
+ return this.actionButtonClass(this.node);
+ },
+ rowClass() {
+ const variant = this.node.variant || "default";
+ if (variant === "path") {
+ return "grid grid-cols-1 sm:grid-cols-[minmax(0,1.15fr)_auto_minmax(0,1.35fr)_auto] gap-x-4 gap-y-2 items-center px-4 py-3 text-sm";
+ }
+ if (variant === "announce") {
+ return "grid grid-cols-1 sm:grid-cols-[auto_minmax(0,0.75fr)_minmax(0,1fr)_minmax(0,1.1fr)] gap-x-4 gap-y-2 items-center px-4 py-3 text-sm";
+ }
+ if (variant === "card") {
+ return "grid grid-cols-1 sm:grid-cols-[minmax(0,1.15fr)_auto_minmax(0,1.35fr)_auto] gap-x-4 gap-y-2 items-center px-4 py-3 text-sm bg-white dark:bg-zinc-950";
+ }
+ if (variant === "announce-card") {
+ return "grid grid-cols-1 sm:grid-cols-[auto_minmax(0,0.75fr)_minmax(0,1fr)_minmax(0,1.1fr)] gap-x-4 gap-y-2 items-center px-4 py-3 text-sm bg-white dark:bg-zinc-950";
+ }
+ return "flex flex-wrap items-center gap-3 text-sm";
+ },
+ badgeClass() {
+ const variant = this.node.variant || "muted";
+ const map = {
+ success: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200",
+ danger: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200",
+ muted: "bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-300",
+ };
+ return map[variant] || map.muted;
+ },
+ },
+ methods: {
+ actionButtonClass(action) {
+ const base =
+ "inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40";
+ if (action.variant === "secondary") {
+ return `${base} border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-zinc-800`;
+ }
+ if (action.variant === "danger") {
+ return `${base} border border-red-300 dark:border-red-800 bg-white dark:bg-zinc-900 text-red-600 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950/30`;
+ }
+ return `${base} bg-blue-600 text-white hover:bg-blue-700`;
+ },
+ },
};
</script>

diff --git a/meshchatx/src/frontend/components/plugins/PluginSlotRenderer.vue b/meshchatx/src/frontend/components/plugins/PluginSlotRenderer.vue
index efe2d146..02120759 100644
--- a/meshchatx/src/frontend/components/plugins/PluginSlotRenderer.vue
+++ b/meshchatx/src/frontend/components/plugins/PluginSlotRenderer.vue
@@ -1,7 +1,7 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
- <div class="plugin-slot space-y-4">
+ <div class="plugin-slot space-y-6">
<PluginSlotNode
v-for="(node, index) in nodes"
:key="index"

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 82fdf277..add3a1cd 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -3063,6 +3063,9 @@ export default {
return matchesSettingSearch(texts, (k) => this.$t(k), this.searchQuery);
},
showSection(sectionKey) {
+ if (sectionKey === "plugins" && GlobalState.pluginsEnabled === false) {
+ return false;
+ }
if (this.settingsSearchActive) {
const keywords = this.sectionKeywords[sectionKey];
if (!keywords) {

diff --git a/meshchatx/src/frontend/js/GlobalState.js b/meshchatx/src/frontend/js/GlobalState.js
index d5c0dcf1..ddf7abc1 100644
--- a/meshchatx/src/frontend/js/GlobalState.js
+++ b/meshchatx/src/frontend/js/GlobalState.js
@@ -5,6 +5,7 @@ const globalState = reactive({
authSessionResolved: true,
authEnabled: false,
authenticated: false,
+ pluginsEnabled: true,
detailedOutboundSendStatus: false,
outboundTransferProgressEnabled: true,
messageTimestampGroupingEnabled: true,

diff --git a/meshchatx/src/frontend/js/ingestUriResultNavigation.js b/meshchatx/src/frontend/js/ingestUriResultNavigation.js
new file mode 100644
index 00000000..b6b2e69f
--- /dev/null
+++ b/meshchatx/src/frontend/js/ingestUriResultNavigation.js
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * @param {Record<string, unknown>} json
+ * @param {{ push: (location: object) => Promise<unknown> }} router
+ * @param {{ info?: (msg: string) => void, error?: (msg: string) => void } | null} [toast]
+ * @returns {Promise<boolean>} true when navigation was handled
+ */
+export async function handleLxmIngestUriResult(json, { router, toast = null }) {
+ if (json.ingest_type === "map_view" && json.map_query) {
+ const mq = json.map_query;
+ const query = {
+ lat: String(mq.lat),
+ lon: String(mq.lon),
+ zoom: String(mq.zoom),
+ };
+ if (mq.layers) {
+ query.layers = mq.layers;
+ }
+ if (mq.label) {
+ query.label = mq.label;
+ }
+ await router.push({ name: "map", query });
+ if (json.status === "error") {
+ toast?.error?.(json.message);
+ } else if (json.message) {
+ toast?.info?.(json.message);
+ }
+ return true;
+ }
+
+ if (json.ingest_type === "docs_view") {
+ const dq = json.docs_query;
+ const rel = dq && typeof dq.reticulum === "string" ? dq.reticulum.trim() : "";
+ if (rel) {
+ await router.push({
+ name: "documentation",
+ query: { reticulum: encodeURIComponent(rel) },
+ });
+ } else {
+ await router.push({ name: "documentation" });
+ }
+ if (json.status === "error") {
+ toast?.error?.(json.message);
+ } else if (json.message) {
+ toast?.info?.(json.message);
+ }
+ return true;
+ }
+
+ return false;
+}

diff --git a/meshchatx/src/frontend/js/plugins/pluginLabels.js b/meshchatx/src/frontend/js/plugins/pluginLabels.js
index 1504f0a1..d1cb356d 100644
--- a/meshchatx/src/frontend/js/plugins/pluginLabels.js
+++ b/meshchatx/src/frontend/js/plugins/pluginLabels.js
@@ -47,10 +47,9 @@ export async function loadPluginLabelMap(apiClient, pluginId, locale, manifest =
for (const code of candidates) {
try {
const assetPath = `${directory}/${code}.json`;
- const response = await apiClient.get(
- `/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${assetPath}`,
- { responseType: "json" }
- );
+ const response = await apiClient.get(`/api/v1/plugins/${encodeURIComponent(pluginId)}/asset/${assetPath}`, {
+ responseType: "json",
+ });
if (response.data && typeof response.data === "object") {
return flattenLocaleMessages(response.data);
}

diff --git a/meshchatx/src/frontend/main.js b/meshchatx/src/frontend/main.js
index fedff423..c66c0def 100644
--- a/meshchatx/src/frontend/main.js
+++ b/meshchatx/src/frontend/main.js
@@ -17,6 +17,7 @@ import { fetchCsrfToken } from "./js/csrfToken.js";
import { registerCoreContributions } from "./js/registries/registerCoreContributions.js";
import { installWsEventBridge } from "./js/registries/wsEventBridge.js";
import { pluginHost } from "./js/plugins/PluginHost.js";
+import GlobalState from "./js/GlobalState.js";
import "./js/HeapMonitor.js";
registerCoreContributions();
@@ -25,7 +26,6 @@ installWsEventBridge();
import App from "./components/App.vue";
import ChangelogModal from "./components/ChangelogModal.vue";
import TutorialModal from "./components/TutorialModal.vue";
-import GlobalState from "./js/GlobalState";
const localeModules = import.meta.glob("./locales/*.json", { eager: true });
const messages = {};
@@ -298,12 +298,6 @@ const router = createRouter({
meta: { isPopout: true },
component: () => import("./components/call/CallPage.vue"),
},
- {
- name: "plugin-transport-node-monitor",
- path: "/plugins/com.meshchatx.transport-node-monitor",
- component: () => import("./components/plugins/PluginPage.vue"),
- props: { pluginId: "com.meshchatx.transport-node-monitor" },
- },
{
name: "plugin-mesh-observatory",
path: "/plugins/com.meshchatx.mesh-observatory",
@@ -417,10 +411,22 @@ function bootstrap() {
splash.remove();
}
void startCodec2ScriptsBackgroundLoad();
- if (GlobalState.authenticated || !GlobalState.authEnabled) {
- void pluginHost.loadEnabledPlugins(window.api, i18n.global.locale.value).catch((error) => {
- console.debug("Plugin host bootstrap failed:", error);
- });
+ void loadPluginsIfEnabled();
+}
+
+async function loadPluginsIfEnabled() {
+ if (!(GlobalState.authenticated || !GlobalState.authEnabled)) {
+ return;
+ }
+ try {
+ const response = await window.api.get("/api/v1/plugins");
+ GlobalState.pluginsEnabled = response.data?.plugins_enabled !== false;
+ if (!GlobalState.pluginsEnabled) {
+ return;
+ }
+ await pluginHost.loadEnabledPlugins(window.api, i18n.global.locale.value);
+ } catch (error) {
+ console.debug("Plugin host bootstrap failed:", error);
}
}

diff --git a/pytest.ini b/pytest.ini
index 5abb60f6..252c03a2 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -2,6 +2,7 @@
testpaths = tests/backend
python_files = test_*.py
python_functions = test_*
+addopts = --basetemp=temp-tests/pytest
markers =
integration: optional tests (live network, subprocess Reticulum, etc.)
lxst_real: tests that require the real LXST Telephone class

diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py
index 0d02b985..d73ddf36 100644
--- a/tests/backend/conftest.py
+++ b/tests/backend/conftest.py
@@ -3,27 +3,32 @@
import asyncio
import os
import socket
-import tempfile
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
import pytest
import RNS
+from tests.backend.support.test_temp_dir import (
+ TEST_COVERAGE_DIR,
+ configure_test_temp_environment,
+ ensure_test_temp_dirs,
+)
+
+configure_test_temp_environment()
+
+os.environ["MESHCHAT_SKIP_STORAGE_LOCK"] = "1"
+os.environ["MESHCHAT_DISABLE_CSRF"] = "1"
+
from meshchatx.meshchat import ReticulumMeshChat
from meshchatx.src.backend.config_manager import ConfigManager
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.schema import DatabaseSchema
-# Set log dir to a temporary directory for tests to avoid permission issues
-# in restricted environments like sandboxes.
-os.environ["MESHCHAT_LOG_DIR"] = tempfile.mkdtemp()
-os.environ["MESHCHAT_SKIP_STORAGE_LOCK"] = "1"
-os.environ["MESHCHAT_DISABLE_CSRF"] = "1"
-
def _ensure_coverage_data_dir() -> None:
+ ensure_test_temp_dirs()
cov_file = os.environ.get("COVERAGE_FILE")
if not cov_file:
return
@@ -36,12 +41,12 @@ _ensure_coverage_data_dir()
def pytest_configure(config):
+ ensure_test_temp_dirs()
_ensure_coverage_data_dir()
worker = os.environ.get("PYTEST_XDIST_WORKER")
- cov_dir = os.environ.get("MESHCHAT_COVERAGE_DIR")
- if worker and cov_dir:
- os.makedirs(cov_dir, exist_ok=True)
- os.environ["COVERAGE_FILE"] = os.path.join(cov_dir, f".coverage.{worker}")
+ if worker:
+ os.makedirs(TEST_COVERAGE_DIR, exist_ok=True)
+ os.environ["COVERAGE_FILE"] = os.path.join(str(TEST_COVERAGE_DIR), f".coverage.{worker}")
@pytest.fixture(scope="session")

diff --git a/tests/backend/support/__init__.py b/tests/backend/support/__init__.py
new file mode 100644
index 00000000..e69de29b

diff --git a/tests/backend/support/test_temp_dir.py b/tests/backend/support/test_temp_dir.py
new file mode 100644
index 00000000..00ff6c37
--- /dev/null
+++ b/tests/backend/support/test_temp_dir.py
@@ -0,0 +1,60 @@
+# SPDX-License-Identifier: 0BSD
+
+"""Redirect backend test filesystem writes to ./temp-tests/ under the repo root."""
+
+from __future__ import annotations
+
+import os
+import shutil
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+TEST_TEMP_ROOT = REPO_ROOT / "temp-tests"
+TEST_WORK_DIR = TEST_TEMP_ROOT / "work"
+TEST_LOG_DIR = TEST_TEMP_ROOT / "logs"
+TEST_COVERAGE_DIR = TEST_TEMP_ROOT / "coverage"
+PYTEST_BASE_TEMP = TEST_TEMP_ROOT / "pytest"
+
+
+def is_xdist_worker() -> bool:
+ return os.environ.get("PYTEST_XDIST_WORKER") is not None
+
+
+def should_reset_test_temp_root() -> bool:
+ if os.environ.get("MESHCHAT_TEST_KEEP_TEMP") == "1":
+ return False
+ return not is_xdist_worker()
+
+
+def reset_test_temp_root() -> None:
+ if not should_reset_test_temp_root():
+ return
+ if TEST_TEMP_ROOT.exists():
+ shutil.rmtree(TEST_TEMP_ROOT, ignore_errors=True)
+
+
+def ensure_test_temp_dirs() -> Path:
+ for path in (TEST_TEMP_ROOT, TEST_WORK_DIR, TEST_LOG_DIR, TEST_COVERAGE_DIR, PYTEST_BASE_TEMP):
+ path.mkdir(parents=True, exist_ok=True)
+
+ work_dir = str(TEST_WORK_DIR)
+ os.environ["TMPDIR"] = work_dir
+ os.environ["TEMP"] = work_dir
+ os.environ["TMP"] = work_dir
+ os.environ["MESHCHAT_LOG_DIR"] = str(TEST_LOG_DIR)
+ os.environ.setdefault("MESHCHAT_COVERAGE_DIR", str(TEST_COVERAGE_DIR))
+ os.environ.setdefault("COVERAGE_FILE", str(TEST_COVERAGE_DIR / ".coverage"))
+ return TEST_TEMP_ROOT
+
+
+def configure_test_temp_environment() -> Path:
+ reset_test_temp_root()
+ return ensure_test_temp_dirs()
+
+
+def subprocess_test_env(extra: dict[str, str] | None = None) -> dict[str, str]:
+ ensure_test_temp_dirs()
+ env = os.environ.copy()
+ if extra:
+ env.update(extra)
+ return env

diff --git a/tests/backend/test_plugin_manager.py b/tests/backend/test_plugin_manager.py
index a4b36ed7..4fca324a 100644
--- a/tests/backend/test_plugin_manager.py
+++ b/tests/backend/test_plugin_manager.py
@@ -18,31 +18,30 @@ class TestPluginManagerInstall:
manager.install_bundled_examples()
plugins = manager.list_plugins()
ids = [plugin["id"] for plugin in plugins]
- assert "com.meshchatx.transport-node-monitor" in ids
assert "com.meshchatx.mesh-observatory" in ids
+ assert "com.meshchatx.transport-node-monitor" not in ids
def test_enable_disable_plugin(self, tmp_path):
manager = _make_manager(tmp_path)
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.transport-node-monitor"
+ plugin_id = "com.meshchatx.mesh-observatory"
enabled = manager.enable(plugin_id)
assert enabled["enabled"] is True
disabled = manager.disable(plugin_id)
assert disabled["enabled"] is False
- def test_invoke_storage_roundtrip(self, tmp_path):
+ def test_storage_roundtrip(self, tmp_path):
manager = _make_manager(tmp_path)
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.transport-node-monitor"
- manager.enable(plugin_id)
- manager.invoke(plugin_id, "setWatchedNodes", {"nodes": ["abc123"]})
- state = manager.invoke(plugin_id, "getState")
- assert state["watched_nodes"] == ["abc123"]
+ plugin_id = "com.meshchatx.mesh-observatory"
+ manager.storage_set(plugin_id, "sample_key", json.dumps(["abc123"]))
+ value = manager.storage_get(plugin_id, "sample_key")
+ assert json.loads(value) == ["abc123"]
def test_permission_denied_for_manager_capability(self, tmp_path):
manager = _make_manager(tmp_path)
manager.install_bundled_examples()
- plugin_id = "com.meshchatx.transport-node-monitor"
+ plugin_id = "com.meshchatx.mesh-observatory"
manager.enable(plugin_id)
with pytest.raises(PermissionError):
manager.call_manager(plugin_id, "unknown.capability", {})
@@ -89,3 +88,17 @@ class TestPluginManagerInstall:
json.dump({"id": "bad id", "version": "1.0.0", "apiVersion": 1}, handle)
with pytest.raises(ValueError):
manager.install_from_directory(plugin_dir)
+
+ def test_plugins_disabled_blocks_install_and_enable(self, tmp_path):
+ class DisabledApp:
+ plugins_enabled = False
+
+ manager = _make_manager(tmp_path, app=DisabledApp())
+ manager.install_bundled_examples()
+ assert manager.list_plugins() == []
+ source = os.path.join(
+ os.path.dirname(__file__),
+ "../../meshchatx/src/backend/data/plugins/mesh-observatory",
+ )
+ with pytest.raises(PermissionError):
+ manager.install_from_directory(os.path.abspath(source))

diff --git a/tests/backend/test_plugin_security.py b/tests/backend/test_plugin_security.py
index 5b11f0ee..5a42cebb 100644
--- a/tests/backend/test_plugin_security.py
+++ b/tests/backend/test_plugin_security.py
@@ -128,7 +128,14 @@ class TestPluginGuard:
with pytest.raises(FileNotFoundError):
manager.enable("com.example.broken")
- @pytest.mark.parametrize("payload", [os.urandom(32), b"not-a-zip", b"\x00\x01\x02"])
+ @pytest.mark.parametrize(
+ "payload",
+ [
+ b"\x91\x0c\xb0\xd9\xe8>\x1eZ \x00\x94\xbe\x9aJ\xf8\xed(u\xa6\xbf\xa9\x05\x8b\x80\xbe\x07\xf7>\x06b\xed",
+ b"not-a-zip",
+ b"\x00\x01\x02",
+ ],
+ )
def test_fuzz_random_install_payloads_are_rejected(self, tmp_path, payload):
manager = _make_manager(tmp_path)
with pytest.raises(Exception):

diff --git a/tests/backend/test_reticulum_live_network.py b/tests/backend/test_reticulum_live_network.py
index 69784dfc..560d7ba2 100644
--- a/tests/backend/test_reticulum_live_network.py
+++ b/tests/backend/test_reticulum_live_network.py
@@ -18,6 +18,8 @@ import sys
import pytest
+from tests.backend.support.test_temp_dir import subprocess_test_env
+
_RUN = os.environ.get("MESHCHAT_LIVE_RETICULUM") == "1"
@@ -43,5 +45,6 @@ finally:
text=True,
timeout=120,
check=False,
+ env=subprocess_test_env(),
)
assert result.returncode == 0, result.stderr + result.stdout

diff --git a/tests/frontend/CommandPalette.test.js b/tests/frontend/CommandPalette.test.js
index b57f615b..453620d0 100644
--- a/tests/frontend/CommandPalette.test.js
+++ b/tests/frontend/CommandPalette.test.js
@@ -2,12 +2,26 @@ import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import CommandPalette from "../../meshchatx/src/frontend/components/CommandPalette.vue";
import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
+import { commandRegistry } from "../../meshchatx/src/frontend/js/registries/commandRegistry.js";
+import { navRegistry } from "../../meshchatx/src/frontend/js/registries/navRegistry.js";
+import { toolsRegistry } from "../../meshchatx/src/frontend/js/registries/toolsRegistry.js";
+import { settingsSectionRegistry } from "../../meshchatx/src/frontend/js/registries/settingsSectionRegistry.js";
+import {
+ registerCoreContributions,
+ resetCoreContributionsForTests,
+} from "../../meshchatx/src/frontend/js/registries/registerCoreContributions.js";
describe("CommandPalette.vue", () => {
let axiosMock;
let routerMock;
beforeEach(() => {
+ resetCoreContributionsForTests();
+ navRegistry.clear();
+ toolsRegistry.clear();
+ commandRegistry.clear();
+ settingsSectionRegistry.clear();
+ registerCoreContributions();
axiosMock = {
get: vi.fn().mockResolvedValue({
data: {

diff --git a/tests/frontend/deepLinks.docs.security.test.js b/tests/frontend/deepLinks.docs.security.test.js
index be302436..304c2ac6 100644
--- a/tests/frontend/deepLinks.docs.security.test.js
+++ b/tests/frontend/deepLinks.docs.security.test.js
@@ -2,7 +2,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import App from "../../meshchatx/src/frontend/components/App.vue";
-import WebSocketConnection from "../../meshchatx/src/frontend/js/WebSocketConnection";
+import { handleLxmIngestUriResult } from "../../meshchatx/src/frontend/js/ingestUriResultNavigation.js";
import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
@@ -14,16 +14,6 @@ vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
},
}));
-vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
- default: {
- send: vi.fn(),
- connect: vi.fn(),
- on: vi.fn(),
- off: vi.fn(),
- destroy: vi.fn(),
- },
-}));
-
describe("meshchatx://docs deep links (security / fuzz)", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -75,18 +65,17 @@ describe("meshchatx://docs deep links (security / fuzz)", () => {
it("onWebsocketMessage docs_view navigates like handleProtocolLink", async () => {
const push = vi.fn().mockResolvedValue(undefined);
- await App.methods.onWebsocketMessage.call(
- { $router: { push } },
+ const handled = await handleLxmIngestUriResult(
{
- data: JSON.stringify({
- type: "lxm.ingest_uri.result",
- status: "success",
- ingest_type: "docs_view",
- message: "Opening documentation.",
- docs_query: { reticulum: "manual/interfaces.html#x" },
- }),
- }
+ type: "lxm.ingest_uri.result",
+ status: "success",
+ ingest_type: "docs_view",
+ message: "Opening documentation.",
+ docs_query: { reticulum: "manual/interfaces.html#x" },
+ },
+ { router: { push }, toast: ToastUtils }
);
+ expect(handled).toBe(true);
expect(push).toHaveBeenCalledWith({
name: "documentation",
query: { reticulum: encodeURIComponent("manual/interfaces.html#x") },
@@ -96,17 +85,16 @@ describe("meshchatx://docs deep links (security / fuzz)", () => {
it("onWebsocketMessage docs_view without docs_query opens documentation index", async () => {
const push = vi.fn().mockResolvedValue(undefined);
- await App.methods.onWebsocketMessage.call(
- { $router: { push } },
+ const handled = await handleLxmIngestUriResult(
{
- data: JSON.stringify({
- type: "lxm.ingest_uri.result",
- status: "success",
- ingest_type: "docs_view",
- message: "Opening documentation.",
- }),
- }
+ type: "lxm.ingest_uri.result",
+ status: "success",
+ ingest_type: "docs_view",
+ message: "Opening documentation.",
+ },
+ { router: { push }, toast: ToastUtils }
);
+ expect(handled).toBe(true);
expect(push).toHaveBeenCalledWith({ name: "documentation" });
});
});

diff --git a/tests/frontend/deepLinks.protocol.security.test.js b/tests/frontend/deepLinks.protocol.security.test.js
index 187db551..3dcb3ac9 100644
--- a/tests/frontend/deepLinks.protocol.security.test.js
+++ b/tests/frontend/deepLinks.protocol.security.test.js
@@ -2,6 +2,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import App from "../../meshchatx/src/frontend/components/App.vue";
+import { handleLxmIngestUriResult } from "../../meshchatx/src/frontend/js/ingestUriResultNavigation.js";
import WebSocketConnection from "../../meshchatx/src/frontend/js/WebSocketConnection";
import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
@@ -93,24 +94,23 @@ describe("App.vue deep link protocol handling (security-oriented)", () => {
it("onWebsocketMessage map_view passes label and layers as opaque query strings", async () => {
const push = vi.fn().mockResolvedValue(undefined);
const marker = "<svg/onload=alert(1)>";
- await App.methods.onWebsocketMessage.call(
- { $router: { push } },
+ const handled = await handleLxmIngestUriResult(
{
- data: JSON.stringify({
- type: "lxm.ingest_uri.result",
- status: "success",
- ingest_type: "map_view",
- message: "Opening map view.",
- map_query: {
- lat: 3,
- lon: 4,
- zoom: 5,
- layers: "discovered",
- label: marker,
- },
- }),
- }
+ type: "lxm.ingest_uri.result",
+ status: "success",
+ ingest_type: "map_view",
+ message: "Opening map view.",
+ map_query: {
+ lat: 3,
+ lon: 4,
+ zoom: 5,
+ layers: "discovered",
+ label: marker,
+ },
+ },
+ { router: { push }, toast: ToastUtils }
);
+ expect(handled).toBe(true);
expect(push).toHaveBeenCalledWith({
name: "map",
query: {

diff --git a/tests/frontend/pluginLabels.test.js b/tests/frontend/pluginLabels.test.js
index c2409a84..239db5b9 100644
--- a/tests/frontend/pluginLabels.test.js
+++ b/tests/frontend/pluginLabels.test.js
@@ -19,9 +19,7 @@ describe("pluginLabels", () => {
it("resolves plugin UI strings with manifest fallback", () => {
expect(resolvePluginUiString({}, "title", { name: "Fallback Name" })).toBe("Fallback Name");
- expect(resolvePluginUiString({ title: "From Bundle" }, "title", { name: "Fallback Name" })).toBe(
- "From Bundle"
- );
+ expect(resolvePluginUiString({ title: "From Bundle" }, "title", { name: "Fallback Name" })).toBe("From Bundle");
});
it("loads plugin locale messages from plugin assets", async () => {


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────